Skip to content

fix(mcp): read publish errors from platforms[] instead of a metadata key the API never sets - #38

Merged
Zernio-Elean merged 8 commits into
developfrom
fix/mcp-post-errors-and-empty-permalink
Aug 19, 2026
Merged

fix(mcp): read publish errors from platforms[] instead of a metadata key the API never sets#38
Zernio-Elean merged 8 commits into
developfrom
fix/mcp-post-errors-and-empty-permalink

Conversation

@Zernio-Elean

@Zernio-Elean Zernio-Elean commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

An integrator could not tell why their posts had failed: every failed post read Error: Unknown error. The MCP post views were reading the error from post.metadata["error"], a key the API never populates. The real text lives in platforms[], the array where each target platform of a post keeps its own errorMessage.

posts_list was worse: it rendered no error line at all, not even the fallback.

Changes

  • src/late/mcp/server.py — new _platform_errors(post) helper. posts_get, posts_list and posts_list_failed all build their error lines from it, as Error (platform): message.
  • tests/test_mcp_post_error_surfacing.py — new, against an httpx.MockTransport fake API, using real production payload shapes.

No spec or generated-model changes here. The platformPostUrl declaration was fixed at the source in zernio-dev/Schedule-Posts-API#2799 and arrived through the regular chore: regenerate from OpenAPI spec pull, which this branch has merged.

Design notes

Selection is by platform status, not by whether a message is present. A platform that published can still carry an errorMessage left over from an earlier attempt. Post 691a85709ed078ff2f35bd16 is the live case: its instagram entry is published and still holds "Publishing failed due to timeout or max retries reached". Selecting on the message would report a platform that published fine as an error.

Two statuses count: failed and cancelled. The status enum has six values that split into in-progress (pending, processing, uploading), success (published), and "done and did not publish" (failed, cancelled). The error views want the third group. cancelled matters because account-disconnect cleanup writes the only actionable reason onto that entry (Account "X" was disconnected), so a failed post targeting one platform that got cancelled used to read Unknown error while the document held the answer.

In-progress entries are excluded because every reset path clears errorMessage, so anything still on one is stale.

Known behaviour change: drafts and scheduled posts can hold a cancelled platform, since retry never resets one, so unfiltered posts_list now renders error lines on them. Deliberate, and pinned by a test.

The Unknown error fallback in posts_list_failed stays. It is still reachable for failed posts whose only platform is pending with no message, or stale-published.

The second defect in the same ticket

posts_get raised 1 validation error for PostGetResponse on TikTok posts that published fine, because the API sends platformPostUrl: "" when TikTok confirms a publish without giving back an id a permalink can be built from, and the spec declared the field as a URI. The fix is the spec change linked above. The tests here guard the regenerated model against a regression that reintroduces format: uri, so it would fail in CI rather than in a customer's MCP client.

Testing

  • 222 passed, 14 skipped
  • ruff check src tests clean
  • mypy src --ignore-missing-imports clean, 116 files
  • Every new test verified failing against the parent commit, not just green after
  • Payloads taken from live records: failed 6a8392cdc6abe639aadbcc21, empty permalink 6a7e23169598cfb3119bb578, cancelled platform 6a74056fa22ac1afb451b7da, draft with a cancelled platform 69047ddcbb4db2cc1794478d, stale-published 691a85709ed078ff2f35bd16

Reviewed by a second model (Fable 5 running the senior-debugger and fix-plan-reviewer checklists) on both the shipped diff and the follow-up plan.

Crisp

https://app.crisp.chat/website/20dea5d6-a684-4c80-b097-2258b0b41421/inbox/session_6e4c63b7-90b7-43a4-a510-165a5670745b/

…inks

Post reading through MCP had two defects reported by an integrator in the
same ticket.

The failed-post views read the error from post.metadata["error"], a key the
API never populates, so posts_get and posts_list_failed fell back to the
literal "Unknown error" while posts_list rendered no error at all. The real
text lives per platform in PlatformTarget.errorMessage, so _platform_errors
now collects it from the failed legs and all three views render it.

Legs that later published can still carry a stale errorMessage, so the leg's
own status selects it rather than the presence of a message; otherwise a
platform that published fine would be reported as an error.

posts_get and posts_list also raised a validation error on TikTok posts that
published fine, because the API emits platformPostUrl: "" when TikTok
confirms a publish without returning an id a permalink can be built from, and
the spec declared the field as a URI. PlatformTarget.platformPostUrl now
matches the permissive declaration already used elsewhere in the spec.

The API emitting "" rather than null is tracked separately; the client has to
accept what is already in the responses either way.
Account-disconnect cleanup cancels a post's legs and writes the reason onto
them ('Account "X" was disconnected'), so a failed post whose only leg was
cancelled rendered "Unknown error" while the document held the answer. That is
the same complaint the ticket opened with, one status short of fixed.

The leg status enum has six values, and they partition into in-flight
(pending, processing, uploading), success (published), and terminal
non-success (failed, cancelled). The error views want that third set, so the
guard now selects it rather than failed alone.

In-flight legs stay excluded on purpose: every reset path clears their
errorMessage, so anything left on one is stale. The "Unknown error" fallback
stays too, since failed posts whose only leg is pending or stale-published
still reach it.

Drafts and scheduled posts can hold a cancelled leg, because retry never
resets one, so unfiltered posts_list now renders those as well. Deliberate,
and pinned by a test.

Refs Crisp session_6e4c63b7.
"Leg" is not a word this codebase uses anywhere else, so the comments
explaining why _platform_errors selects what it selects were the hardest part
of the change to read. A post holds one entry in platforms[] per target
platform, and that is what the comments now call it.

Comments, docstrings and test names only. No behaviour change.
…s-and-empty-permalink

# Conflicts:
#	src/late/models/_generated/models.py
The platformPostUrl declaration is being fixed at the source, in the API
repo's public/openapi.yaml, and reaches this repo through the regular
"chore: regenerate from OpenAPI spec" pull. Carrying a hand-made copy of it
here would collide with that regeneration for no gain.

This PR therefore keeps only what is genuinely SDK-side: the MCP post views
reading the error from platforms[] instead of the metadata key the API never
populates.

The empty-permalink coverage goes with the declaration it depends on. Those
tests assert that PlatformTarget accepts platformPostUrl: "", which stays
false until the regenerated model lands, so they follow in their own PR once
the spec pull has gone through.
@Zernio-Elean Zernio-Elean changed the title fix(mcp): surface real publish errors and read back posts without a permalink fix(mcp): read publish errors from platforms[] instead of a metadata key the API never sets Aug 19, 2026
…llows it

The spec fix landed in the API repo and reached this repo through the regular
regeneration, so PlatformTarget.platformPostUrl is str | None and the payload
the API actually sends for some TikTok publishes parses again.

These tests pin that: posts_get and posts_list must return a post whose
permalink is an empty string, and the model must keep rejecting nothing while
still accepting a real URL. A spec regression that reintroduces format: uri
fails here rather than in a customer's MCP client.
The test asserted that a real permalink still parses, using a TikTok URL that
AnyUrl leaves untouched, so it passed with and without the fix and guarded
nothing.

A bare-origin URL is the case that separates them: AnyUrl renders
"https://example.com" back as "https://example.com/", so before the fix the
client handed consumers a permalink the platform never sent. The test now
pins that the value comes back byte for byte, and fails against the previous
model.
@Zernio-Elean
Zernio-Elean merged commit 4f5458c into develop Aug 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant